Skip to content

feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list - #13415

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-11971-grants-cache-leg-b
Aug 30, 2026
Merged

feat(core): authz grants cache — #11633 leg B: coarse invalidation, default off, expiry-boundary rule, bypass list#13415
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-11971-grants-cache-leg-b

Conversation

@os-elon

@os-elon os-elon commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Fixes #11971

Leg B of the accepted #11633 cross-request caching design (maintainer acceptance 2026-08-25, verbatim 「接受你的建议,继续」), built on the #11968 substrate (PR #12652). Clause-②: yes (authorization answers) — this PR is opened as a draft and parked for a CONTRACT_REVIEW_TIER reviewer; that is the expected outcome. The needs:contract-review re-attach now that a reviewable diff exists is left to the seat that owns that mechanism (per the 2026-08-28 #12887 ruling quoted on the card).

What ships

resolveUserAuthzGrants (packages/core) can cache its resolved envelope across requests, governed by OS_AUTHZ_GRANTS_CACHE_TTL_MS.

  • Fork 4 — default 0, cache OFF, off is a real path. With the shipped default the open call returns undefined with zero side effects: no engine middleware, no epoch subscription, no entry — pinned by asserting the engine double's middleware count and epoch listener count are both zero and the second resolution re-issues the identical query multiset.
  • Fork 1A — coarse invalidation, two seams. (1) A cache-owned engine middleware retires everything on any write to the watched set (sys_member, sys_user_position, sys_user_permission_set, sys_position, sys_position_permission_set, sys_permission_set, sys_user), bumping AFTER the write completes so an in-flight resolution stamps a pre-write generation and dies on arrival. (2) An engine write-epoch subscription retires wholesale on every non-write reason: metadata (a permission set can be DECLARED — plugin-security bumps the engine epoch for it), remote (peer hints carry no object; wholesale is the payload's stated contract), manual. The raw epoch alone is deliberately NOT consumed for local writes — it advances on writes to every object, which would revive the measured sys_session trap.
  • The ruled keying traps hold. sys_session stays out of the watched set — pinned with a control showing the session-activity write DID advance the engine epoch while the cache survived. A sys_member write retires the organization's entries (coarse retires everything, which contains the ruled behaviour) — pinned as: user X's org_user_ids changes when user Y's row is written.
  • Expiry-boundary rule. Entries expire at min(ttl, nextBoundary); nextGrantValidityBoundary (new, in grant-validity.ts, sharing isGrantActive's parser) scans exactly the rows the validity predicate was applied to — including currently-inactive rows, because a future valid_from is a flip the timer must catch too. Pinned in both directions with an injected clock and a one-hour TTL.
  • TTL is the correctness contract. The bus only narrows: pinned by a two-node pair with a bridge double (one hop, no clock advance) and without one (stale inside the TTL — the accepted window — and converged past it with no message ever delivered).
  • Ruled bypass list, re-derived by symbol (both files had moved off the ruling's line numbers): plugin-security/src/explain-engine.ts buildContextForUser (now line 473→481 region) and service-automation/src/plugin.ts setUserGrantsResolver (was :812, now :869 region) pass bypassGrantsCache: true with the ruling's reason preserved in comments. Bypass reads nothing from and writes nothing into the cache — both halves pinned.
  • Docs + changeset: the canonical env-vars table row drops its "no cache reads this value yet" caveat; changeset = core minor, plugin-security/service-automation patch.

One measured deviation from the design's lean (not from the ruling)

Design §4 B.2 leans toward caching the seedless envelope and re-applying seeds outside, conditioned on seeding being "a pure prepend, which must be pinned, not assumed". Measured before implementing: it is not a pure prepend. Seeds flow into derivations — a seed named in ORGANIZATION_ADMIN_GRANTS moves the ADR-0095 posture rung, and seeded email/ai_seat suppress the sys_user read (needsUserRow), so a seedless resolution issues a query the seeded path must not issue. Re-deriving those outside the resolver would be a second copy of authorization logic — the #10348 drift shape. So seeds are part of the cache key (the design's other named option), which keeps every cached answer bit-identical to its own uncached resolution by construction. Pin 5 carries the isolation, ordering, and suppressed-read halves.

Test plan (design §7: pins 1–7, 9 + the required ablation)

New suite packages/core/src/security/resolve-user-grants-cache.test.ts (28 tests) + pin 6 in plugin-security/src/explain-engine.test.ts. The batch-equivalence harness (recording double + 11-fixture matrix) moved to resolve-authz-context.batch-equivalence.testkit.ts so the identity pins reuse the fixtures without re-registering that suite; the goldens stay in the test file. The seam double's write verbs open with assertEngineUpdateDispatch/assertEngineDeleteDispatch (metadata-core — the non-cycle edge) and the RETAINED ledger records it.

  • Pin 1: identity over all 11 fixtures — cached deep-equals uncached including array order, AND the hit issues zero reads. Plus clone isolation (a caller mutating its answer cannot poison the next).
  • Pin 2: revoke and grant through the engine observed by the next resolution, no clock advance, TTL one hour — asserting the capability/posture at the end of the chain, never "the cache was cleared".
  • Pin 3: validity boundary, both directions, no write anywhere.
  • Pin 4: peer membership (B.3). Pin 5: seed isolation (B.2). Pin 6: explainer observes a revocation the cache provably still holds (control asserts the cached path IS stale first). Pin 7: zero-means-off (above). Pin 9: two-node bus/TTL pair.
  • Decline rule: a ql without the seams resolves uncached every time (never degrades to TTL-only).

⭐ Ablation of write-invalidation (required by the ruling) — invalidation is load-bearing

Declared direction before running: with both gen-bump seams removed and only the TTL/boundary expiry left, the read-after-write pins go RED; boundary/TTL/identity pins stay GREEN.

  • Mutation: both state.gen += 1; sites in resolve-user-grants-cache.ts replaced with a no-op marker. Proven on disk before reading any result: marker grep = 2, original-text grep = 0, git hash-object = 5257ceb... vs HEAD blob 8a3875a....
  • No rebuild needed for the ablated legs, and that was verified rather than assumed: the pins import the cache module by in-package relative path, so vitest resolves the mutated source directly (no dist in the resolution path; the RED result itself confirms the mutated code ran).
  • Result, exactly as declared — 5 RED / 23 GREEN: revoke-through-engine RED, grant-through-engine RED, metadata/manual retirement RED, peer-membership RED, bus-hop RED; TTL convergence, validity boundary, identity, off-path all GREEN.
  • Restore proven by state, not exit code: git checkout HEAD -- $ABS_PATH (absolute path, trap on EXIT/INT/TERM), then git diff HEAD empty, git status clean, blob hash back to 8a3875a..., marker grep 0; rerun 28/28 green. Cycle run twice — once at the feature commit, once at the final head ae0ec14b6.

Verification (all at final head ae0ec14b6 unless noted)

  • Gate family derived at edit time (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, derivation stamped at ae0ec14b6 after merging origin/main): 39 commands. 38 green. check:dual-build-cjs-loads exits 3 = NOT MEASURED (its own text: prerequisite not met, full workspace dist required — CI runs it after the full build). check:skill-examples initially exited 1 for the same prerequisite class (client dists not built); after turbo run build --filter=@objectstack/client-react... it is a real green: "260 prose examples type-check across 3 surfaces".
  • check:engine-double-contract (self-derived, not in the path-derived list): initially red on the new double, fixed by pinning to the producer predicates + --write ledger row; final run green ("653 (file, verb) rows held by the RETAINED ledger").
  • Full-repo pnpm lint (eslint . --no-inline-config): exit 0 (at merge commit 0f348d3a4; the two commits after it touch one test file + the ledger json, both relinted green in the final union's lint-family gates).
  • Suites: core 44 files / 1088 tests green at ae0ec14b6; plugin-security 89 files / 1637 tests green and its three-program typecheck green with the new test file confirmed IN the tsc program via --listFiles (zero-residue regime of PR test(plugin-security): compile the 89 test files no tsc program read #13395 — nothing parked); service-automation 91 files / 1091 tests green (at 0f348d3a4; untouched since). check:nul-bytes green.
  • bypassGrantsCache verified present in core's built dist/index.d.ts (plugin-security resolves core unaliased, per the KNOWN_UNALIASED_TEST_IMPORTS ledger), and service-automation/src/plugin.ts compiles clean against it (0 tsc errors in that file; the package's pre-existing test-file noise is environmental and untouched).

Serial fences respected

No touches to permission-set-projection.ts / packaged-permission-set-restore-leg.test.ts (card #12020), none of PR #13371's eight files (service-automation/src/plugin.ts is not among them), no service-datasource/runtime manifests (card #12943 — its optional-peers change arrived via the origin/main merge, untouched), no plugin-sharing/plugin-auth (PR #13397). No skills/**, no content/docs/releases/**, no pm:* label or assignee changes.


Generated by Claude Code

claude added 3 commits August 30, 2026 10:05
…ation, TTL=0 default, expiry-boundary rule, ruled bypass list

The UserAuthzGrants envelope resolveUserAuthzGrants produces can now be
cached across requests, governed by OS_AUTHZ_GRANTS_CACHE_TTL_MS (default
0 = off, a real path with zero engine footprint). When enabled: coarse
invalidation on any engine write to a watched authorization object
(sys_session deliberately excluded — the measured keying trap), wholesale
retirement on non-write epoch reasons (metadata/remote/manual), entry
expiry at min(ttl, nextValidityBoundary) because ADR-0091 windows flip
with no write anywhere, seeds in the cache key (measured: seeding is NOT
a pure prepend — posture and the suppressed sys_user read both depend on
seeds), and clones served so callers cannot poison entries. The explain
engine and runAs:'user' automation runs take the ruled force-fresh path.

Pins: identity over the 11-fixture matrix (zero reads on a hit),
read-after-write revocation/grant with no clock advance, the sys_session
trap, validity boundaries in both directions, peer-membership coarse
retirement, seed isolation, TTL=0 bit-identity, and the two-node bus/TTL
convergence pair. The batch-equivalence harness moved to a .testkit.ts so
the identity pins reuse the fixtures without re-registering that suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
…contract

check:engine-double-contract flagged makeSeamQl — its update()/delete()
accepted calls the real ObjectQL refuses. Both verbs now open with the
producer's own predicates (assertEngineUpdateDispatch /
assertEngineDeleteDispatch from @objectstack/metadata-core — the
non-cycle edge for a package objectql depends on), the pins spell their
writes legally (multi delete carries multi:true, the session-activity
update is by-id), and the RETAINED ledger records the new pinned double.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/plugin-security, @objectstack/service-automation, touching 26 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via expiresAt (symbol))
  • content/docs/api/error-catalog.mdx (via sys_permission_set (literal))
  • content/docs/automation/approvals.mdx (via sys_user_position (literal))
  • content/docs/data-modeling/objects.mdx (via sys_position_permission_set (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/deployment/environment-variables.mdx (via resolveUserAuthzGrants (symbol), sys_member (literal), sys_permission_set (literal), sys_position (literal), sys_position_permission_set (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/deployment/tenancy-modes.mdx (via sys_member (literal))
  • content/docs/kernel/contracts/auth-service.mdx (via expiresAt (symbol))
  • content/docs/permissions/administrator-guide.mdx (via sys_user_position (literal))
  • content/docs/permissions/authentication.mdx (via expiresAt (symbol), sys_member (literal), sys_user_position (literal))
  • content/docs/permissions/authorization.mdx (via sys_permission_set (literal), sys_position (literal), sys_position_permission_set (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/delegated-administration.mdx (via sys_member (literal), sys_permission_set (literal), sys_position (literal), sys_position_permission_set (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/permission-sets.mdx (via sys_member (literal), sys_permission_set (literal), sys_position_permission_set (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/positions.mdx (via sys_member (literal), sys_position (literal), sys_position_permission_set (literal), sys_user_position (literal))
  • content/docs/permissions/profiles.mdx (via sys_position_permission_set (literal))

8 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_member (literal), sys_position_permission_set (literal), sys_user_permission_set (literal))
  • content/docs/releases/index.mdx (via sys_position (literal))
  • content/docs/releases/v12.mdx (via sys_permission_set (literal))
  • content/docs/releases/v13.mdx (via sys_permission_set (literal), sys_position (literal), sys_position_permission_set (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/releases/v14.mdx (via sys_position (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/releases/v15.mdx (via sys_permission_set (literal), sys_position (literal), sys_user_position (literal))
  • content/docs/releases/v16.mdx (via resolveUserAuthzGrants (symbol), sys_member (literal), sys_user_permission_set (literal), sys_user_position (literal))
  • content/docs/releases/v17.mdx (via resolveUserAuthzGrants (symbol), sys_member (literal), sys_permission_set (literal), sys_position (literal), sys_user_permission_set (literal), sys_user_position (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 29 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3e31c2691cf312b871bc6b23ee6f911a2e197789packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2537a4045c83ff4bdd5045ad93981f8b5d8b6430 — the merge of head 1b877ef27f50cda7faa4a1f521b7a9d7b256c2ce into base 3e31c2691cf312b871bc6b23ee6f911a2e197789, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2537a4045c83ff4bdd5045ad93981f8b5d8b6430 && git checkout 2537a4045c83ff4bdd5045ad93981f8b5d8b6430
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e31c2691cf312b871bc6b23ee6f911a2e197789 1b877ef27f50cda7faa4a1f521b7a9d7b256c2ce && git checkout -B drift-repro 3e31c2691cf312b871bc6b23ee6f911a2e197789 && git merge --no-ff 1b877ef27f50cda7faa4a1f521b7a9d7b256c2ce

node scripts/docs-audit/affected-docs.mjs --json 3e31c2691cf312b871bc6b23ee6f911a2e197789

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3e31c2691cf312b871bc6b23ee6f911a2e197789 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…h — census population restored to baseline

CI's check:system-context-census (landed on main after this branch was cut,
outside the path-derived gate set) went red on the harness extraction: the
recording double's isSystem-recording line, excluded from the census for as
long as it lived in a .test.ts file, entered the population when it moved to
a sibling .testkit.ts — the census classifies test code BY PATH (.test. /
.spec. / tests/ / __tests__/ / qa/) and the .testkit.ts suffix is not in
that rule. The file is test scaffolding (consumed only by suites, extracted
from one), so the honest repair is classification, not a hand-written page
row: it now lives under security/__tests__/, inside the census's own
published exclusion, and the population returns to its 109-site / 45-file
baseline — page, counts, and the #4707 ruling's quoted premise all
untouched. Whether .testkit.ts should join the census's path rule generally
is the gate owner's call and is reported on the card, not taken here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WkdHQwHr2KQmaX7P1BHzi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz caching leg B: grants cache — coarse invalidation, default TTL=0 (off), expiry-boundary expiry, bypass list

3 participants